Skip to content

fix(orchestrator): bound the whole sweep, not one more call inside it - #374

Merged
kjgbot merged 6 commits into
mainfrom
fix/sweep-aggregate-budget
Aug 25, 2026
Merged

fix(orchestrator): bound the whole sweep, not one more call inside it#374
kjgbot merged 6 commits into
mainfrom
fix/sweep-aggregate-budget

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 25, 2026

Copy link
Copy Markdown
Member

Do not merge — factory-lead holds the gate.

Three unbounded calls have wedged this sweep in a single day, on three different
transports. Each was real, each was bounded, and each time the wedge came back
one layer down. This PR does not bound a fourth. It makes the class survivable.


A. The aggregate budget

The property: no sweep can be in flight for longer than its budget, whatever
it is waiting on. Elapsed time is charged against one timer for the whole
pass
, so it does not matter which await is slow, how many there are, or how
many times the sweep retries one of them.

Why a per-call bound cannot do this

bound what it covers what it misses
relayfileOperationTimeoutMs (#351/#368) one relayfile call the retry loop around it (L3); any other transport
reconcileTimeoutMs (#296) the caller's wait, from outside runOnce() the sweep itself keeps running, so every later cycle coalesces onto the wedged promise (runOnce(), the #runOnceInFlight branch, main factory.ts:2986-2989)
sweepBudgetMs (this PR) the whole pass, from inside #runOnceWithDiscoveryFence see does not cover below

Expiring from inside the fence is the entire difference. The sweep unwinds,
the discovery lease goes back, #runOnceInFlight clears, and the next cycle
claims a fresh lease and runs clean.

Mechanism, plainly — what it can and cannot interrupt

budget.run() is a race, not a cancellation. Same honest limitation #368
documented for withRelayfileCallDeadline, stated for the same reason.

  • CAN abandon an in-flight await, on any transport, and unwind the sweep.
  • CANNOT stop the abandoned work. The socket stays open, the SDK's own retry
    loop keeps running, and a side effect already in flight still lands.
  • PARTIALbudget.signal aborts at expiry, so anything that honours an
    AbortSignal is really cancelled. Nothing in the sweep consumes it yet: the
    relayfile client mints its own per-call signal and that file is owned by
    factory-wedge-layer2-0825 this week. It is exported so wiring it is one line,
    not a redesign.
  • assertNotExpired() is a between-await check and is worth nothing against
    a call that never returns. What it buys is that an already-abandoned pass
    unwinds at its next loop iteration if it ever regains control, instead of
    running to completion beside the sweep that replaced it.

Teardown is bounded separately, on purpose

On the path that matters the budget is spent by construction, so teardown cannot
run under it — every step would reject and the lease would never be released,
and releasing it is the half that makes the next cycle clean. An unbounded
release would re-create this wedge one layer down
, which is the pattern this
PR exists to end. It gets its own 30 s deadline. An abandoned release costs an
orphaned lease for one expiry window, which a later sweep reclaims as an orphan
(claim.reclaimedLease).

The default deliberately changes no timing

sweepBudgetMs defaults to reconcileTimeoutMs (90 min) and is clamped to it,
so no sweep that survives today is killed by this. The number is a policy
dial; the mechanism is the deliverable. Tightening it has a real cost: the
checkpoint commits only at the end of the pass, so a budget below realistic
cold-mirror hydration (#36 measured 61 min in production) turns a slow boot into
a loop that never makes progress — the trap reconcileTimeoutMs already
documents. Recovery inside 90 minutes therefore needs either a tighter
sweepBudgetMs at deploy time (your call, one config key) or the L3 retry bound
the other lane owns.


B. Is discovery hostage to the sweep?

The coupling you named is necessary, and it is not what cost us dispatch. A
different one is, and this PR removes it.

discoveryDeferred: 'sweep-in-flight' (main factory.ts:3071) — necessary, keep it

It fires only when claimDiscoverySweep finds another owner holding the
durable lease. What the lease protects is the discovery checkpoint: two
concurrent passes would both advance the same cursor via
#finalizeDiscoveryCheckpoint / completeDiscoverySweep, so one would commit a
watermark covering trees the other listed and the uncovered trees would never be
re-read. That is a correctness invariant, not a convenience.

And it is cheap. A deferred pass returns immediately (main :3062-3072) — it
does not block, it settles successfully, and it costs one interval of freshness.

Note it never fired during this incident: within one process runOnce()
coalesces rather than defers
(main :2986-2989). Which answers your other
question —

Why discoveryDeferred went "sweep-in-flight"None between 0.1.74 and 0.1.75

discoveryDeferred is a latched marker on the last SETTLED pass. It is
written only by #recordReadinessSweepOutcome, which runs only on a pass that
succeeded, and cleared only on the failure path (main factory.ts:2310).

So the change is a symptom of L2 working, not new behaviour. The operational
lesson is the one that matters: discoveryDeferred is not evidence that
discovery was being deferred at the moment you read it. It can be arbitrarily
stale. The same latch applies to lastError (see the note at the end).

The coupling that actually cost all dispatch — main factory.ts:1983 / :2021 / :2039 / :2366

Discovery is not only the sweep. The live subscription drain is a full,
independent dispatch path that never touches the discovery lease:

#enqueueLiveEvent (:2358) → #scheduleLiveEventDrain (:2366) →
#handleLiveEventsWithYield#handlePreparedLiveChange#handleChange
triageIssue (:7837) → dispatch (:7847).

Its gate at :2366 checks #liveEventDrainScheduled, #liveEventDrainActive,
#deferLiveEventDrain and #started. There is no sweep gate. It dispatches
happily while a sweep is in flight.

Except for one thing:

1983:    this.#deferLiveEventDrain = true      // before the startup backfill
2021:        const report = await this.runOnce()   // ← unbounded
2039:      this.#deferLiveEventDrain = false     // in the finally

:2039 is inside a finally around an unbounded runOnce(). A wedged
startup backfill therefore means start() never returns, #deferLiveEventDrain
stays true forever, and the live drain never starts. Both discovery paths
die together. That is why a wedged sweep meant zero dispatch instead of stale
dispatch.

This PR fixes exactly that, with no extra change. runOnce() now always
settles, so :2039 always runs, so the live drain always starts and keeps
dispatching while later sweeps are degraded. A slow sweep now costs freshness —
the outcome you asked for in B — because the durable safety net degrades while
the event-driven path stays up.

This is not a reading, it is a test. must-fire: a live daemon whose sweep is wedged still shuts down starts a real daemon whose first post-claim call never
returns. Against origin/main's factory.ts it fails after 4044 ms with
start never returnedstart() itself never comes back, which is :2039
never running. With the budget, start() returns and stop() completes.

I have not removed the :3071 deferral, per your instruction, and I would
not: the lease is load-bearing for checkpoint correctness.


Proof

Fail-first verified by mechanism, not by assertion colour: with only
factory.ts reverted the end-to-end must-fire fails after 4038 ms with
sweep never settled — the sweep never settles, which is the production defect
in the production shape. (The must-not-fire passes with the fix reverted, as it
must: a healthy sweep is unaffected either way.)

must-fire (end to end). A sweep whose first post-claim call never returns is
aborted at its budget with the abandoned phase named; the lease handback is
observed on the store, not inferred; the next cycle runs a fresh sweep and
dispatches.

must-fire (primitive).

  • Three 40 ms calls under a 120 ms budget: the third is rejected because the
    sweep is out of time, not because it is slow. This is the aggregate
    property no per-call deadline has.
  • A bounded-but-always-failing call inside an unbounded retry loop ends at
    the budget after more than one attempt. That is the L3 shape.
  • The signal aborts at expiry; a spent budget refuses to start new work against
    the dependency it just gave up on.

must-not-fire.

  • A healthy sweep under a snug 30 s budget produces results identical to an
    unbounded control — pulled, dispatched, skipped, and the spawn list.
    Without this the trivial wrong fix (abort everything immediately) passes.
  • A caller's own failure still surfaces as itself, never re-clothed as a budget
    expiry.
  • With sweepBudgetMs: 0 the same hung call stays pending, so every
    rejection above is attributable to the budget and not to the wrapper.

What this does NOT cover

  • It does not make anything faster and it does not find the hanging call. A
    wedged dependency still costs one whole budget per cycle.
  • It does not cancel. See Mechanism above.
  • The abandoned pass runs concurrently with the sweep that replaces it if it ever
    unsticks. It cannot commit a checkpoint (the store's epoch guard) but its
    in-flight side effects still land.
  • start()'s own pre-backfill watermark read is outside this budget, and
    outside the orchestrator's bounds entirely.
    #startLiveSubscription calls
    #currentEventHighWatermark() (factory.ts:2192) before the backfill; it
    awaits mount.getEventHighWatermark() under a bare try/catch, NOT through
    #withRelayfileOperation, so relayfileOperationTimeoutMs does not reach it.
    What bounds it in production is one layer lower — the deployed client's own
    #bounded() (relayfile-cloud-mount-client.ts:1057, fix(mount): bound the relayfile change feed so a hung tail read cannot wedge the readiness sweep #368). A MountClient
    without that deadline has none here at all, and start() hangs forever.
    Found while writing the shutdown test; not fixed here because the file that
    would fix it belongs to the L2 lane this week. (Thanks to the review for
    catching that my first wording credited the orchestrator with a bound it does
    not have.)
  • Shutdown is bounded by the grace window plus one teardown deadline, not by
    the budget — but a sweep whose teardown is ALSO abandoned still leaves an
    orphaned lease for one expiry window.
  • The #runOnceWithReadinessDeadline abandoned-wait bookkeeping is unchanged;
    a budget expiry reaches it as an ordinary sweep failure. The seven bounded readiness reconciliation tests that cover that
    accounting now pass sweepBudgetMs: 0 — at the budget's default there is no
    abandoned-but-still-running sweep left for them to observe, which is the fix;
    0 selects the backstop underneath it, the same control idiom fix(mount): bound the relayfile change feed so a hung tail read cannot wedge the readiness sweep #368 used.
  • The default changes no timing (see above).

Review fixes at this head

Every finding below was valid and is fixed, each with its own must-fire /
must-not-fire:

finding fix
a fixed 90-min sweepBudgetMs default rejects any config that already tightened reconcileTimeoutMs, and silently caps one that loosened it the omitted budget is derived from its SIBLING in a .transform(), never from a constant (resolvedSweepBudgetMs)
an abandoned #performRunOnce can write a stale tree into the REPLACEMENT sweep's checkpoint #isStaleDiscoveryContinuation() compares the discoveryEnumerationPass ALS epoch — which follows the async continuation, so it carries the epoch that ISSUED the read — against the live one; applied to the checkpoint write and to overload attribution
an abandoned pass could still dispatch after teardown the dispatch loop gets the same budget guard as the read loop
a lease claimed after the budget gave up on the claim was stranded for a full lease window a compensating release is attached to the abandoned claim
unref'd deadline timers let Node exit before the budget fires, so a one-shot runOnce() returns without reporting the wedge or releasing the lease both deadline timers are referenced; dispose() clears them from a finally on every path

The first one was the dangerous one: it would have taken Factory down on
deploy
for any config that had tuned reconcileTimeoutMs, because the schema
throws before the daemon starts.

A second round found two more, both this PR's own lesson recurring inside it:

finding fix
stop() outlives the sweep it started (#301), so a wedged sweep made shutdown as long as the budget — 90 min at the default stop() arms a grace timer over the drain and then calls budget.expire(), routing the sweep into its ORDINARY abort path. NOT unref(), which is in direct tension with the P2 above: it would also let Node exit before the budget fires
the lease claim was issued before budget.run could reject a spent budget the claim is issued inside the callback; the promise is kept outside for the late-release compensation

On shutdown: before this PR that same drain was unbounded, so shutdown on a
wedged sweep never returned at all. A sweep that starts while #stopping is
already set is expired on creation, so it cannot hand shutdown a fresh budget.

The claim-ordering finding is scoped honestly in its test: "already spent on
entry" is not reachable by construction — every path into the helper is preceded
by a budget.run that throws first, and I verified by ablation that the
integration test still passes without the fix. What makes the microtask gap
worth closing is new in the same commit: stop() can now spend a budget
asynchronously.

On the /evidence document — corrections

Three of the four readings do not survive the code, and none of them weakens the
case for this PR:

  1. lastError is stale, and does not describe the wedged pass.
    #readinessReconcileLastError is cleared only on a successful pass (main
    factory.ts:2265). The wedged pass has not settled, so that breaker message
    belongs to the pass that failed at 07:51:59, not the one in flight since
    07:52:59. Same latch as discoveryDeferred.
  2. lastDurationMs: 4368 is a failure's duration, not a success's. It is
    written on both paths (:2304 on failure). lastFailureAtMs 07:51:59 is
    2 ms after fleetControlPlane.lastFailureAtMs, so 4368 ms is how long the
    pass took to fail via the breaker. The last success was at 07:49:49. The
    underlying instinct is still right — 07:47–07:50 shows sub-interval sweeps —
    but this field is not the evidence for it.
  3. The 07:52:59 "coincidence" is arithmetic. Both 60 s timers were armed by
    the same event: the reconcile interval (intervalMs: 60000) from the failure
    at 07:51:59.211, and the breaker's resetTimeoutMs: 60000 from
    07:51:59.209. They expire together by construction. Not a lead.
  4. The 20 s roster bound is real and does fire. probe() wraps roster()
    in withTimeout (src/fleet/control-plane-circuit.ts:245-269), a genuine
    race. So the 38-minute hang is almost certainly not in roster().
    The unbounded thing on that path is the mutation after the probe
    (:180-183: "applies the local roster deadline without imposing a timeout on
    mutations") — a spawn/resume during dispatch has no deadline at all. That
    is a live L4 candidate. I have not chased it, per the brief. Under this
    PR it is a degraded sweep rather than the end of dispatch, which is the point.

Your framing stands where it counts: the failing transport is not the same one
twice, and an aggregate budget is agnostic to which one it is.

@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 2e31da9a2801fa1cd08b4fb58e3dd7ec93aefac0.

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 48 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f3500919-244a-4937-98a1-f6fe2bcfa7b3

📥 Commits

Reviewing files that changed from the base of the PR and between 2c2dd86 and 1b48ff0.

📒 Files selected for processing (3)
  • src/orchestrator/factory.ts
  • src/orchestrator/sweep-budget.test.ts
  • src/orchestrator/sweep-budget.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 01452f9d-5af8-4b53-9dc0-aa2be54b3e07

📥 Commits

Reviewing files that changed from the base of the PR and between f1dc713 and 2c2dd86.

📒 Files selected for processing (6)
  • src/config/schema.ts
  • src/orchestrator/factory.test.ts
  • src/orchestrator/factory.ts
  • src/orchestrator/sweep-budget.test.ts
  • src/orchestrator/sweep-budget.ts
  • src/types.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The PR adds configurable aggregate budgets for discovery sweeps. It applies shared deadlines across sweep phases, bounds teardown, compensates delayed lease claims, prevents stale writes, and adds unit and end-to-end coverage.

Changes

Discovery sweep budgeting

Layer / File(s) Summary
Budget configuration and public contract
src/config/schema.ts, src/types.ts
Adds sweep budget defaults, resolution, cross-field validation, and the required sweepBudgetMs live subscription option.
Shared budget and teardown runtime
src/orchestrator/sweep-budget.ts
Adds shared expiry handling, cancellation signals, phase-specific errors, timer disposal, and bounded teardown deadlines.
Factory sweep enforcement and cleanup
src/orchestrator/factory.ts
Applies the budget across discovery operations, lease handling, retries, commits, teardown, enumeration, dispatch, and stale asynchronous continuations.
Budget behavior and regression coverage
src/orchestrator/sweep-budget.test.ts, src/orchestrator/factory.test.ts
Tests expiry, cancellation, retries, lease cleanup, shutdown, stale writes, healthy sweeps, and configurations without aggregate budgets.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: ⚪ Minimal · up to 2c2dd

The PR bounds the full sweep and teardown without an identified call-site contract issue; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Factory
  participant SweepBudget
  participant StateStore
  participant DiscoverySession
  Factory->>SweepBudget: start aggregate sweep budget
  Factory->>StateStore: claim discovery lease
  Factory->>DiscoverySession: prepare and execute discovery
  DiscoverySession-->>Factory: return discovery results
  Factory->>StateStore: checkpoint and commit results
  SweepBudget-->>Factory: signal expiry
  Factory->>StateStore: release or compensate lease
Loading

Suggested reviewers: kjgbot, miyaontherelay

Poem

A rabbit sets a deadline bright
The sweep hops through the night
Leases tidy, timers cease
Stale trails fade into peace
Fresh trees grow on schedule true

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 4 files. (2 skipped: 2 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: it bounds the entire orchestrator sweep instead of adding another per-call limit.
Description check ✅ Passed The description is detailed and directly explains the aggregate sweep budget, lease handling, teardown behavior, limitations, and test coverage.
Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 4 files. (2 skipped: 2 too large.)

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sweep-aggregate-budget

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Three unbounded calls have wedged this sweep in a single day, on three
different transports. Each was real, each was bounded, and each time the wedge
came back one layer down: the FACTORY_STATE Durable Object calls
(factory-cloud#78), the relayfile change-feed tail reads (#368, shipped and
verified in 0.1.75), then the retry of the now-bounded call. This does not
bound a fourth. It makes the class of failure survivable.

The property it establishes: NO SWEEP CAN BE IN FLIGHT FOR LONGER THAN ITS
BUDGET, whatever it is waiting on. Elapsed time is charged against ONE timer
for the whole pass, so it does not matter which await is slow, how many there
are, or how many times the sweep retries one of them. The next unbounded call
degrades a sweep instead of ending dispatch.

WHY A PER-CALL BOUND CANNOT DO THIS. `relayfileOperationTimeoutMs` bounds one
relayfile call and cannot see the retry loop around it or a call on another
transport. `reconcileTimeoutMs` bounds the CALLER'S WAIT from outside
`runOnce()`, so expiry leaves the sweep running and every later cycle
coalesces onto the same wedged promise (factory.ts `runOnce()`, the
`#runOnceInFlight` branch) — which is why the deployed daemon never recovers.
The budget expires from INSIDE `#runOnceWithDiscoveryFence`, so the sweep
unwinds, the lease goes back, `#runOnceInFlight` clears, and the next cycle
claims a fresh lease.

MECHANISM, PLAINLY. `budget.run()` is a race, not a cancellation — the same
limitation #368 documented, stated for the same reason.
  CAN: abandon an in-flight await, from any transport, and unwind the sweep.
  CANNOT: stop the abandoned work. The socket stays open, the SDK's own retry
  loop keeps running, and a side effect already in flight still lands.
  PARTIAL: `budget.signal` aborts at expiry, so anything honouring an
  AbortSignal is really cancelled — nothing in the sweep consumes it yet (the
  relayfile client mints its own per-call signal and that file is owned by
  another lane this week); it is exported so wiring it is one line.
  `assertNotExpired()` is a between-await check and is worth nothing against a
  call that never returns, but it does make an abandoned pass unwind at its
  next loop iteration rather than run to completion beside its replacement.

TEARDOWN IS BOUNDED SEPARATELY. On the path that matters the budget is spent
by construction, so teardown cannot run under it or the lease would never be
released — and releasing it is the half that makes the next cycle clean. An
unbounded release would re-create this wedge one layer down. It gets a 30 s
deadline; an abandoned release costs an orphaned lease for one expiry window,
which a later sweep reclaims (`claim.reclaimedLease`).

DEFAULT IS THE EXISTING ENVELOPE, DELIBERATELY. `sweepBudgetMs` defaults to
`reconcileTimeoutMs` (90 min) and is clamped to it, so no sweep that survives
today is killed by this. The value is a policy dial, the mechanism is the fix.
Tightening it has a real cost: the checkpoint commits only at the end, so a
budget below realistic cold-mirror hydration (#36 measured 61 min in
production) makes a slow boot a loop that never progresses.

TESTS (11), must-fire/must-not-fire for each:
- must-fire, end to end: a sweep whose first post-claim call never returns is
  aborted at its budget naming the phase, the lease release is OBSERVED on the
  store, and the next cycle runs a fresh sweep and dispatches. Fail-first
  verified by mechanism: with only factory.ts reverted it fails after 4038 ms
  with "sweep never settled" — the pass never settles, exactly as production.
- must-fire, primitive: three 40 ms calls under a 120 ms budget — the third is
  rejected because the SWEEP is out of time, not because it is slow; a
  bounded-but-always-failing call inside an unbounded retry loop ends at the
  budget (the L3 shape) after more than one attempt; the signal aborts; a spent
  budget refuses to start new work against the dependency it gave up on.
- must-not-fire: a healthy sweep under a snug 30 s budget produces results
  IDENTICAL to an unbounded control (pulled, dispatched, skipped, spawns) —
  without this the trivial wrong fix, abort everything, passes; a caller's own
  failure still surfaces as itself and is never re-clothed as a budget expiry;
  with `sweepBudgetMs: 0` the same hung call stays pending, so every rejection
  above is attributable to the budget and not to the wrapper.

WHAT THIS DOES NOT COVER.
- It does not make anything faster or find the hanging call. A wedged
  dependency still costs one whole budget per cycle.
- It does not cancel. See MECHANISM above.
- The abandoned pass runs concurrently with the sweep that replaces it if it
  ever unsticks. It cannot commit a checkpoint (the store's epoch guard) but
  its in-flight side effects still land.
- Two `stop()`/shutdown paths and the `#runOnceWithReadinessDeadline`
  abandoned-wait bookkeeping are unchanged; a budget expiry reaches them as an
  ordinary sweep failure.
- The default changes no timing. Recovery inside 90 minutes needs either a
  tighter `sweepBudgetMs` or the L3 retry bound the other lane owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2

Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
@khaliqgant
khaliqgant force-pushed the fix/sweep-aggregate-budget branch from 2e31da9 to b032e85 Compare August 25, 2026 08:47
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head b032e85d0ce25f78c448b47b87458b733c09a086.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e31da9a28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/config/schema.ts Outdated
Comment thread src/orchestrator/factory.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed against the latest diff

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/orchestrator/factory.ts Outdated
Comment thread src/orchestrator/factory.ts
Comment thread src/config/schema.ts Outdated
Comment thread src/config/schema.ts Outdated
Comment thread src/orchestrator/factory.ts
Comment thread src/orchestrator/sweep-budget.ts Outdated
Comment thread src/orchestrator/sweep-budget.test.ts Outdated
…dget backstop

The seven `bounded readiness reconciliation` cases assert on a sweep that the
readiness deadline abandoned and that is STILL RUNNING. The aggregate budget
makes that state unreachable at its default — it aborts the sweep at or before
that deadline, so there is nothing left in flight to observe. That is the fix,
not a regression.

Each now passes `sweepBudgetMs: 0`, which selects the pre-#372 backstop those
assertions are actually about: the #296/#301 abandoned-wait accounting, still
the behaviour when the budget is disabled and still the shape a sweep degrades
to if a teardown path cannot be abandoned. `0` as the disable value is the same
control idiom #368 used for `operationTimeoutMs`.

Adds the positive counterpart, which the redirected cases can no longer state:
a live daemon whose first post-claim call never returns still completes
`start()` and `stop()`, because the sweep is aborted rather than abandoned.
Fail-first verified by mechanism against `origin/main`'s factory.ts: it fails
after 4044 ms with `start never returned`. That is deliverable B demonstrated
rather than argued — `#deferLiveEventDrain = false` sits in a `finally` around
that unbounded `runOnce()` (main factory.ts:1983/2021/2039), so a wedged
startup backfill also kills the live-event dispatch path, which is why a hung
sweep meant zero dispatch instead of stale dispatch.

Also documents a gap the shutdown test exposed and this PR does NOT close:
`#startLiveSubscription` reads the event high-watermark before the backfill and
outside any sweep, so that read is bounded only by the per-call relayfile
deadline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 5c656f98e553ce9fd29a6697cb07b61360d238a0.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/orchestrator/sweep-budget.test.ts
…abandoned pass

Five findings, all valid at 5c656f9, each with its own must-fire/must-not-fire.

1. THE DANGEROUS ONE. A fixed 90-minute `sweepBudgetMs` default is ABOVE any
   config that had already tightened `reconcileTimeoutMs`, so the cross-field
   check rejected it and `FactoryConfigSchema.parse` threw — Factory would not
   have started. It also silently capped a config that loosened the timeout
   above 90 minutes. The omitted budget is now derived from its SIBLING in a
   `.transform()`, never from a constant, and `resolvedSweepBudgetMs` is the one
   rule the schema and the orchestrator's `start()` clamp both use.

2. An abandoned `#performRunOnce` could write a stale tree listing into the
   REPLACEMENT sweep's checkpoint: `#rememberDiscoveryTree` reads the shared
   `#discoverySession` fresh, and by the time a late continuation resolves that
   is the next sweep's. `#isStaleDiscoveryContinuation()` compares the
   `discoveryEnumerationPass` epoch — an AsyncLocalStorage store, so it follows
   the async continuation and still carries the epoch that ISSUED the read —
   against the live one. It is the same fence the tree-read counters already
   used. Applied to the checkpoint write and to overload attribution, so a 429
   that arrives after its sweep was abandoned cannot drive the replacement's
   ratchet.

3. The dispatch loop gets the same budget guard as the read loop, so a pass
   abandoned during enumeration cannot dispatch after its lease went back.

4. A lease claimed after the budget gave up on the claim was stranded: nobody
   would renew, commit or release it, so every later sweep deferred for a whole
   lease window. A compensating release is now attached to the abandoned claim.
   Fail-first verified by mechanism — with the compensation ablated the test
   fails with `stranded lease was never released`.

5. Unref'd deadline timers let Node exit before the budget fires. Under a
   one-shot `runOnce()` whose only pending work is a promise nothing else
   references, the command would return without reporting the wedge or
   releasing the lease. Both deadline timers are referenced now; they live for
   at most one budget and `dispose()` clears them from a `finally`.

Also, on the review's reading of a comment: the pre-backfill watermark read is
bounded neither by the sweep budget NOR by anything in the orchestrator —
`#currentEventHighWatermark` (factory.ts:2192) awaits the mount directly under a
bare try/catch. What bounds it in production is one layer lower, the deployed
client's own `#bounded()` (relayfile-cloud-mount-client.ts:1057, #368). The
comment now says which layer, because a `MountClient` without that deadline has
no bound here at all.

And the e2e must-fire no longer risks blaming a phase string for a timing
stall: the budget has 400 ms of headroom over two in-memory calls, and
`hungCalls` is asserted before the phase so a mis-timed run names the real
cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 2c2dd86eecbe23d7487ca68243dd4a98e95cdbf4.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/orchestrator/sweep-budget.ts
Comment thread src/orchestrator/factory.ts Outdated
Two findings at 2c2dd86, both this PR's own lesson recurring inside this PR.

1. THE BOUND BECAME THE WEDGE. `stop()` deliberately outlives the sweep it
   started (#301, the `#readinessReconcileAbandonedWait` drain), so a wedged
   sweep makes shutdown exactly as long as the sweep budget — 90 minutes at the
   default. Referencing the timer did not create that (before this PR the drain
   was unbounded, so shutdown was unbounded too), but it is the same trap the
   teardown deadline already answers one layer down, and an operator restarting
   a wedged container is the person who pays.

   NOT fixed by `unref()`. That is the trivially wrong version: it also lets
   Node exit before the budget fires, so a one-shot `runOnce()` returns having
   neither reported the wedge nor released the lease — the P2 that made the
   timer referenced in the first place. The two asks are in tension and only a
   shutdown-specific path satisfies both.

   `stop()` now arms a grace timer over the drain; after `STOP_TEARDOWN_TIMEOUT_MS`
   it calls `budget.expire()` on every in-flight sweep, routing them into the
   ordinary abort path — lease released, teardown bounded — instead of holding
   the process. The grace is what keeps an ordinary restart from discarding a
   sweep that was about to commit. A sweep that starts while `#stopping` is
   already set is expired immediately, so it cannot hand shutdown a fresh
   90-minute budget.

2. The lease claim is now issued INSIDE the budget callback, so a spent budget
   rejects the phase without opening a lease it could only hand straight back.
   A lease taken after expiry makes every later sweep defer — the same "later
   cycles wait on a pass that is already over" failure this PR's own comparison
   names in `reconcileTimeoutMs`.

Pairs, and their fail-first, verified by ablation:
- must-fire: a live daemon whose PERIODIC sweep wedges under a 60 s budget still
  completes `stop()` inside 4 s. With the grace ablated it hangs to the vitest
  timeout — shutdown waiting out the budget, which is the defect.
- must-not-fire: the budget timer appears in `process.getActiveResourcesInfo()`
  while a sweep runs and is gone the moment `dispose()` runs. This is what
  fails for the `unref()` version — that list contains only resources KEEPING
  THE EVENT LOOP ALIVE, so an unref'd timer never appears — and it also pins
  the other half: a settled sweep leaves nothing behind, which is what makes a
  referenced 90-minute timer affordable.
- must-fire: a sweep aborted in the fleet-probe phase opens no lease and
  releases none. Scoped honestly in the test: every entry into
  `#claimDiscoverySweepUnderBudget` is preceded by a `budget.run` that throws
  first, so "already spent on entry" is a microtask race rather than a
  reachable state, and moving the claim inside the callback closes it by
  construction. The guarantee that does the work — `budget.run` never invokes
  its thunk once spent — is asserted directly on the primitive.
- must-not-fire: a healthy sweep still claims exactly once and dispatches. The
  trivially wrong way to stop a spent budget claiming is to stop claiming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 462aa3e28017798b5c1e6f5e6e38360f58e4b675.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/orchestrator/sweep-budget.test.ts
Comment thread src/orchestrator/factory.ts
@khaliqgant

Copy link
Copy Markdown
Member Author

Scope note before anyone merges this: it bounds the stall, it does not prevent it

Recording this on the PR so the caveat travels with the change rather than living in a status thread.

Measured against the deployed worker this morning, readinessReconcile had been stalled on a single call since 09:07:32Z:

sample inFlightMs missedPasses consecutiveFailures
10:14:12.170Z 3999898 66 0
10:15:57.210Z 4104938 68 0

The inFlightMs delta is 105.040s across a wall-clock delta of 105.040s — ratio 1.00000. That is one call that never returned, not a slow loop; missedPasses advances exactly one per 60s interval while consecutiveFailures stays at 0, because nothing ever fails.

The container booted at 09:03:24Z, which is after factory-cloud#78 merged, so the bounded FACTORY_STATE calls are already in that build and the stall recurred anyway — four minutes after boot.

Why that matters for this PR. The aggregate budget defaults to 90 minutes: DEFAULT_READINESS_RECONCILE_TIMEOUT_MS = 90 * 60_000 (src/config/schema.ts:62), DEFAULT_DISCOVERY_SWEEP_BUDGET_MS derives from it (:84), and resolvedSweepBudgetMs clamps the budget so it can never exceed reconcileTimeoutMs (:92). Against a stall that was 68 minutes old, this change would not yet have released it.

What it does buy is recovery, not speed. On the deployed build the reconcile timeout rejects the caller's wait and leaves runOnce() running, so every later cycle coalesces onto the wedged pass — which is exactly the discoveryDeferred: "sweep-in-flight" with missedPasses climbing one-per-60s observed above, and it never ends. This PR rejects from inside #runOnceWithDiscoveryFence, releasing the lease and clearing #runOnceInFlight so the next cycle starts clean. That converts wedged forever into wedged for at most the budget.

So: necessary, not sufficient. Please do not merge this believing it is the cure for the outage. The budget is also a config dial (sweepBudgetMs, schema.ts:131), and lowering it has a documented cost — the sweep commits its checkpoint only at the end, and #36 measured 61 minutes of cold-mirror hydration in production, so a budget under that turns a slow boot into a loop that never completes a pass. Decide that on evidence, not by reflex.

CI on this branch is currently red on package and is being worked; that is tracked separately and is not a reason to relax any assertion here.

Answers both open review threads on #374.

`stop()` armed the sweep-budget grace timer only after awaiting
`#heldAgentDeadlineSweepInFlight`, so an unrelated in-flight held-agent
sweep silently extended a wedged discovery sweep's reprieve from `grace`
to `held-agent sweep duration + grace` — unbounded if that sweep never
returns, which is precisely the bound this change exists to provide. The
grace is a timer; arming it costs nothing, so it now starts the clock at
the moment shutdown starts, and the teardown it guards moved inside its
`try` so the timer is still cleared on every path.

Covered by a new must-fire that observes WHEN the lever arms rather than
that it exists: a discovery sweep is wedged, a held-agent sweep is parked
mid-release through the fleet, and the shutdown counter is read 3.2s into
`stop()` — past the 2.5s grace, far short of the 60s budget. It fails
`expected undefined to be 1` against the previous ordering.

Also wraps the one test that arms the real 90-minute timer in
`try/finally`. Without it a failing assertion left a *referenced*
`Timeout` in the worker, so the failure would present as a hung suite
instead of a named assertion. Proven with a temporary must-fire /
must-not-fire pair: the old shape leaves the timer active after a
throw, the new one does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 372b13bc-44a2-45a5-b5cc-aa228ccca39d
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 1e9d2ec94e199b99438be78ccdb22ed031afc8cb.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/orchestrator/sweep-budget.test.ts Outdated
@khaliqgant

Copy link
Copy Markdown
Member Author

This PR is an outage fix — but it is inert at its default value

Flagging this before the merge decision, because the framing matters.

The outage. Factory's discovery sweep is wedged in production right now and has dispatched nothing for 39+ hours. Full measurements in #372, but in short: one sweep started 11:48:55Z and never returned, inFlightMs grows 1:1 with wall clock, missedPasses climbs one per minute, discoveryDeferred: "sweep-in-flight", and consecutiveFailures sits at 0/3 the entire time because a hang throws nothing and the breaker counts only failures. Canaries #350 and #364 have sat 39h and 20h with zero acknowledgement despite passing the deployed safety gate.

Why this PR is the right mechanism. The budget expires from inside #runOnceWithDiscoveryFence, so the sweep unwinds, the discovery lease is released, and #runOnceInFlight clears — which is precisely the coalescing that produces the absorbing state (factory.ts:2982-2988 returns the in-flight promise to every later pass). It also converts the hang into a real rejection, so consecutiveFailures finally moves, which partially closes #372 as well. And the DiscoverySweepPhase enum would name the await the sweep died on — currently unobservable, which is why the specific blocking call is still UNKNOWN.

Why merging it alone would change nothing.

  • sweepBudgetMs defaults to reconcileTimeoutMs.
  • DEFAULT_READINESS_RECONCILE_TIMEOUT_MS = 90 * 60_000 (schema.ts:62) — 90 minutes.
  • The deployed template config/factory.config.template.json sets no reconcileTimeoutMs, reconcileIntervalMs, or sweepBudgetMs, and container-env.mjs passes no override.
  • Observed container lifetime is ~50 minutes.

So the budget resolves to 90 minutes against a ~50-minute container lifetime and would never fire. Meanwhile state: 'stalled' is declared at 10 minutes (READINESS_RECONCILE_STALL_INTERVALS = 10, public-health.ts:64/491).

Therefore: this is an outage fix only if it lands together with an explicit sweepBudgetMs set near the 10-minute stall threshold. As a bare merge it is routine and production stays wedged. That distinction belongs in the merge decision.

CI is currently red / UNSTABLE on this branch; a lane is discriminating flake from real failure (note the known flakes #373 and #342 both touch the failing suites). I am not merging — the merge gate is the principal's.

… grace

The new must-fire read the counter after a fixed 3.2s wait against a 2.5s
grace — a few hundred milliseconds of headroom, which on a loaded worker
is a new flake. This suite already carries two (#342, #373), and adding
a third inside the PR whose subject is a wedge is the wrong trade.

Polling costs the discrimination nothing: the held-agent sweep stays
parked until the test releases it, so against the previous ordering
`stop()` never reaches the arming call at all and the poll can only end
in its own deadline. Re-measured both directions — pre-fix ordering:
exit=1, "the shutdown lever never armed while an unrelated held-agent
sweep was in flight"; with the fix: exit=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 372b13bc-44a2-45a5-b5cc-aa228ccca39d
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 1b48ff0238656f7339dd62b1cc0c2755031133c4.

@khaliqgant

Copy link
Copy Markdown
Member Author

Corrections to my comment above, and a status change

Four things in my earlier comment need correcting. A lane verified my claims independently and disputed two of them; it was right, and I would rather correct them here than have anyone act on the originals.

1. CI is no longer red — this PR is green and merge-ready

I said "CI is currently red / UNSTABLE". That is now stale. At head 1b48ff0: CI: success, all five jobs green, and the prior head 1e9d2ec was fully green too — two consecutive green runs. mergeStateStatus: CLEAN, mergeable: MERGEABLE, 0 of 15 review threads unresolved.

The earlier red at 462aa3e was two pre-existing flakes, proven on a control ref that predates this PR: teammate-mcp.test.ts (#373) and factory.test.ts slackReplyRoutesFencedDuringDrain (#342). The strongest single piece of evidence is CI run 32821385637 on main at 952d450e — this PR's exact merge-base — failing with the identical teammate-mcp.test.ts failure on a ref containing none of this change. The counter string in the second failure appears zero times in this diff.

2. My "set it near the 10-minute stall threshold" recommendation was risky — prefer ~15 min

This is the correction that matters most, because it is the one someone might have acted on.

schema.ts:76-82 documents that a sweep budget set below realistic cold-mirror hydration converts a slow boot into a loop that never makes progress — the sweep checkpoints only at the end, so it restarts from zero each time. #36 measured 61 minutes for that hydration in production, and the budget covers startup backfill too. A 10-minute budget sits uncomfortably close to that cliff, and the failure mode it risks — a boot loop — is worse than the stall it fixes.

Recommended instead:

"liveSubscription": { "sweepBudgetMs": 900000 }

resolvedSweepBudgetMs = min(900000, reconcileTimeoutMs) = 15 min, so this single key is sufficient and reconcileTimeoutMs keeps its 90-minute envelope. 15 min sits three orders of magnitude above the observed warm sweep (lastDurationMs: 917 — under one second), below container lifetime so it can actually fire, and further from the #36 cliff than 10 min.

3. container-env.mjs does not exist — I named a file that isn't there

I wrote that "container-env.mjs passes no override". No such file exists at scripts/ or anywhere in the org by filename search. I should not have stated it.

The substance still holds, by better evidence: an org-wide code search returns 0 hits for sweepBudgetMs, liveSubscription, reconcileTimeoutMs and reconcileIntervalMs in factory-cloud, the deployed template has no liveSubscription block at all, and scripts/render-config.mjs injects none. Corroborated live — /healthz reports intervalMs: 60000, exactly the schema default.

4. The "~50 minute container lifetime" is not independently measured

I stated ~50 min as though it were established. It rests on an inference (repeated dependency-park comments implying fresh processes), and a direct check found one container alive at 34 minutes and still running. One sample is not a distribution. Treat the lifetime as unknown; the argument does not depend on the exact figure, only on it being well under 90 minutes, which is not in doubt.

Also: the coalescing site is factory.ts:3056-3059 at current head — my 2982-2988 was the pre-PR ref.

What stands unchanged

Merging this alone still does not fix the production wedge. The budget resolves to 90 minutes with no config override, so nothing engages. The PR makes the wedge recoverable in principle and nameable in practice (its DiscoverySweepPhase enum would identify the await the sweep dies on — currently UNKNOWN), but the config change has to ship with it. The wedge is still live: latest samples show inFlightMs past 1.8M and missedPasses at 30, consecutiveFailures still 0/3.

Merge gate remains the principal's; I have not merged.

@kjgbot
kjgbot merged commit 00d51ad into main Aug 25, 2026
9 checks passed
@kjgbot
kjgbot deleted the fix/sweep-aggregate-budget branch August 25, 2026 13:32
miyaontherelay added a commit that referenced this pull request Aug 25, 2026
… tree

A sweep went silent for 11m53s and then reported stalled (inFlightMs 727833,
missedPasses 12) while the process stayed healthy — liveHeartbeat logging
throughout, log ring lossless, consecutiveFailures 0. Nothing was failing. The
sweep was slow by construction, in `resolveIssuePrFromMount`, which answers
"which PR belongs to this issue" by reading every mounted PR record one at a
time.

Four defects, all in the same walk.

1. `#resolveIssuePr` could not scope to a repository. `resolveIssuePrFromMount`
   has always honoured `opts.repo`, but `#resolveIssuePr`'s own opts had no
   `repo` field, so it forwarded `undefined` and walked EVERY configured
   repository — 21 in the live workspace — to find a PR that can only live in
   one. None of its four call sites nor the `probePrResolver` port could scope
   it. Adds `repo?: string` and threads the routing answer through all of them
   via `#probeRepoForIssue`, which reuses `dependencyRepoForIssue` — the same
   helper `#dependencyIsTerminalOrMerged` already uses for its own probe.
   Ambiguous routing still walks unscoped: narrowing on a guess would miss a PR
   that is really there.

2. The mount hit never populated the cache it reads. `#resolveIssuePr` reads
   `#probePrResolvedCache` at the top, then runs the mount walk FIRST — the
   common hit — and returned without ever writing it. The cache had a reader
   and no writer on the hot path, so the whole walk repeated per caller, per
   sweep, forever. Now cached on the same terms as the gh branch: same key,
   same TTL, same draft exclusion.

3. The walk read most pull requests twice. `githubPullRoots` returns two roots
   for one repository — the nested `<owner>/<repo>/pulls/` layout and the flat
   `<owner>__<repo>/pulls/by-id/` alias — and unions them into a Set keyed by
   PATH STRING, so one PR under two spellings counted twice. Deduped on the
   identity the path already carries via `githubPullPathParts`, which costs no
   read. Paths that carry no PR identity (`_index.json`, per-PR `comments/*`)
   are left in the walk and still read exactly as before.

4. The read loop was invisible. `listTree` is wrapped by `#listRelayfileTree` —
   named, timed, logged. The `readFile` per candidate ran in a bare try/catch
   that swallows failures into `undefined`, with no logger, counter or progress
   line, which is why twelve minutes of real work was indistinguishable from a
   hung process for three prior investigation layers. Adds progress reporting on
   the same cadence helper the ready-issue read loop uses, plus a
   `probePrMountReads` counter.

Also, two things found while fixing the above:

- The cache invalidation on completion deleted only the BARE issue key, never
  the `:open` / `:legacy` suffixed variants `#resolveIssuePr` actually writes.
  Every `openOnly` probe — i.e. the completion path — was never invalidated.
  Harmless while the mount branch wrote nothing; a live correctness bug the
  moment it does. Now clears the whole key family.

- `#dependencyIsTerminalOrMerged` does not go through `#resolveIssuePr` (it must
  not fall back to gh), so it saw no cache at all, and
  `#terminalDependencyIdentities` memoises only the TRUE answer. A dependency
  that is not merged was re-walked in full for every issue declaring it, on
  every sweep. Adds a sweep-scoped memo for the negative answer, cleared beside
  the terminal set so a PR merging between sweeps is still observed. This is the
  path that produced the reported repro.

Relationship to #374, which bounds the whole sweep: complementary, not
redundant. #374 stops a wedge burning unbounded wall-clock — the seatbelt. This
removes the reason the walk is expensive — the brakes. relayfile-adapters#271
would remove the walk entirely by putting `headRef` in the pull index row.

NOT FIXED, deliberately: no early break on a maximal-score match. The sort is
`b.score - a.score || b.prNumber - a.prNumber`, so a score-30 hit does not win
until every higher-numbered candidate is known to score no better, and
`readProbePrCandidate` takes `pr.number` from the payload rather than the path,
so path order does not prove PR-number order. Semantics could not be shown
preserved, so per the brief the dedupe ships and the early break does not.

No index fast path either: `pulls/_index.json` rows carry no `headRef`, and the
primary match (score 30) is a branch match, so the index cannot exclude any PR
from consideration and a title hit (score 20) must never be returned while an
unread branch match could outrank it. Instead the resolver now logs WHY it fell
back — index absent, shape unrecognised, or present without `headRef` — so the
day adapters#271 lands shows up in the logs rather than passing unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
miyaontherelay added a commit that referenced this pull request Aug 25, 2026
… tree (#377)

* fix(orchestrator): stop the probe PR resolver re-walking the whole PR tree

A sweep went silent for 11m53s and then reported stalled (inFlightMs 727833,
missedPasses 12) while the process stayed healthy — liveHeartbeat logging
throughout, log ring lossless, consecutiveFailures 0. Nothing was failing. The
sweep was slow by construction, in `resolveIssuePrFromMount`, which answers
"which PR belongs to this issue" by reading every mounted PR record one at a
time.

Four defects, all in the same walk.

1. `#resolveIssuePr` could not scope to a repository. `resolveIssuePrFromMount`
   has always honoured `opts.repo`, but `#resolveIssuePr`'s own opts had no
   `repo` field, so it forwarded `undefined` and walked EVERY configured
   repository — 21 in the live workspace — to find a PR that can only live in
   one. None of its four call sites nor the `probePrResolver` port could scope
   it. Adds `repo?: string` and threads the routing answer through all of them
   via `#probeRepoForIssue`, which reuses `dependencyRepoForIssue` — the same
   helper `#dependencyIsTerminalOrMerged` already uses for its own probe.
   Ambiguous routing still walks unscoped: narrowing on a guess would miss a PR
   that is really there.

2. The mount hit never populated the cache it reads. `#resolveIssuePr` reads
   `#probePrResolvedCache` at the top, then runs the mount walk FIRST — the
   common hit — and returned without ever writing it. The cache had a reader
   and no writer on the hot path, so the whole walk repeated per caller, per
   sweep, forever. Now cached on the same terms as the gh branch: same key,
   same TTL, same draft exclusion.

3. The walk read most pull requests twice. `githubPullRoots` returns two roots
   for one repository — the nested `<owner>/<repo>/pulls/` layout and the flat
   `<owner>__<repo>/pulls/by-id/` alias — and unions them into a Set keyed by
   PATH STRING, so one PR under two spellings counted twice. Deduped on the
   identity the path already carries via `githubPullPathParts`, which costs no
   read. Paths that carry no PR identity (`_index.json`, per-PR `comments/*`)
   are left in the walk and still read exactly as before.

4. The read loop was invisible. `listTree` is wrapped by `#listRelayfileTree` —
   named, timed, logged. The `readFile` per candidate ran in a bare try/catch
   that swallows failures into `undefined`, with no logger, counter or progress
   line, which is why twelve minutes of real work was indistinguishable from a
   hung process for three prior investigation layers. Adds progress reporting on
   the same cadence helper the ready-issue read loop uses, plus a
   `probePrMountReads` counter.

Also, two things found while fixing the above:

- The cache invalidation on completion deleted only the BARE issue key, never
  the `:open` / `:legacy` suffixed variants `#resolveIssuePr` actually writes.
  Every `openOnly` probe — i.e. the completion path — was never invalidated.
  Harmless while the mount branch wrote nothing; a live correctness bug the
  moment it does. Now clears the whole key family.

- `#dependencyIsTerminalOrMerged` does not go through `#resolveIssuePr` (it must
  not fall back to gh), so it saw no cache at all, and
  `#terminalDependencyIdentities` memoises only the TRUE answer. A dependency
  that is not merged was re-walked in full for every issue declaring it, on
  every sweep. Adds a sweep-scoped memo for the negative answer, cleared beside
  the terminal set so a PR merging between sweeps is still observed. This is the
  path that produced the reported repro.

Relationship to #374, which bounds the whole sweep: complementary, not
redundant. #374 stops a wedge burning unbounded wall-clock — the seatbelt. This
removes the reason the walk is expensive — the brakes. relayfile-adapters#271
would remove the walk entirely by putting `headRef` in the pull index row.

NOT FIXED, deliberately: no early break on a maximal-score match. The sort is
`b.score - a.score || b.prNumber - a.prNumber`, so a score-30 hit does not win
until every higher-numbered candidate is known to score no better, and
`readProbePrCandidate` takes `pr.number` from the payload rather than the path,
so path order does not prove PR-number order. Semantics could not be shown
preserved, so per the brief the dedupe ships and the early break does not.

No index fast path either: `pulls/_index.json` rows carry no `headRef`, and the
primary match (score 30) is a branch match, so the index cannot exclude any PR
from consideration and a title hit (score 20) must never be returned while an
unread branch match could outrank it. Instead the resolver now logs WHY it fell
back — index absent, shape unrecognised, or present without `headRef` — so the
day adapters#271 lands shows up in the logs rather than passing unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

* fix(orchestrator): never scope a probe to repos.default, and key the probe cache by repo

Two #377 review findings from cubic-dev-ai. Both were correct; the first was a
correctness regression this PR introduced.

P1 — `#probeRepoForIssue` scoped every probe to `repos.default` whenever the
issue carried no label or project evidence. Routing precedence is byLabel,
byProject, keywordRules, default, and `dependencyRepoForIssue` can see neither
the triage decision nor `keywordRules` — those match on issue TEXT through
triage. So for a keyword-routed issue it answered `repos.default` while dispatch
had opened the PR in the keyword-selected repository. The probe then walked one
repository, confidently, and found nothing: "no PR" reported for an issue that
has one, and the completion path acts on that answer. That is strictly worse
than the slow walk this PR set out to remove, and it contradicted the docstring
sitting two lines above it.

Threading the real triage decision was not reachable: all five probe call sites
take only a `LinearIssue`, and at completion time the decision no longer exists.
So the fallback is now the unscoped walk — `dependencyRepoForIssue` grows an
opt-out `allowDefault` (default unchanged for its four other callers) and the
probe wrapper passes `false`. Ambiguity widens the walk; it never narrows it.
The dedupe and cache in this same PR already blunt the cost.

P2 — `repo` narrows which pull requests a resolution can even see, so it is a
resolution dimension, but it was absent from the cache and gh-backoff keys. A
route change could therefore serve the previous repository's PR, and the
completion path probes and CLOSES what it is handed.

Adding that dimension exposed a second, pre-existing defect: the completion
sweep wrote its draft-PR backoff under a BARE issue state key while
`#completionPrForIssue` read the suffixed one. They agreed only by accident, and
the new suffix broke that accident — caught by `gh PR fallback skips draft PRs
and backs off repeated unresolved lookups`, which went red. Both maps now build
their key through one shared `#probePrCacheKey`, so the two writers cannot
drift again. Every dimension stays a trailing `:`-prefixed segment, so the
completion invalidation added in this PR keeps clearing the whole key family.

NOT SHIPPED: a test for P2's stale cross-repo hit. Probe scope is a pure
function of (issue, config) at all five call sites, and the completion path
clears the whole key family, so no public path varies the scope for one issue
inside the TTL. Every way to force it needed a production test hook, and this
file has no precedent for reaching into internals. P2 ships as defensive
correctness plus the real backoff-key fix its test DID catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
miyaontherelay added a commit that referenced this pull request Aug 25, 2026
…e cadence that cannot

THIS COMMIT DOES NOT ADD A BOUND. It publishes the ones that already exist,
because their absence from the health stanza has now been read twice as their
absence from the code — including in the brief that asked for this fix.

WHAT THE STANZA SAID. A wedged 0.1.76 published:

    "readinessReconcile": {
      "state": "healthy", "consecutiveFailures": 0,
      "failureThreshold": 3, "inFlightMs": 268232, "intervalMs": 60000
    }

`intervalMs` is a scheduler tick and cannot preempt anything. Next to an
`inFlightMs` climbing 1:1 with wall clock it is indistinguishable from an
unbounded hang, and there was no field that could tell the two apart. The
reading taken from it — "there is no timeoutMs, so nothing bounds this" — is
false, and it is the reading this stanza invites.

WHAT IS ACTUALLY BOUNDING THAT PASS. Three deadlines, all live on this path in
0.1.76: `relayfileOperationTimeoutMs` per call (#351/#368),
`readinessReconcileTimeoutMs` on the caller's wait (#296), and the aggregate
`sweepBudgetMs` from #374 — `#reconcileReadyIssues` -> `#runOnceWithReadinessDeadline`
-> `runOnce()` -> `#runOnceWithDiscoveryFence` -> `startDiscoverySweepBudget`.
`readinessReconcile` IS the discovery sweep's health stanza; sweep-budget.ts
names it as such. The pass was bounded. It was bounded at 90 minutes, because
`sweepBudgetMs` derives from `reconcileTimeoutMs`, so at 268 s it had 89
minutes left to run and no field said so.

Two numbers now ship: `timeoutMs` (ends the wait) and `sweepBudgetMs` (unwinds
the sweep and hands the lease back). The second is the one that answers "when
does this recover", which is the question every reader of this stanza has
actually been asking.

`missedPasses` also moves onto the heartbeat record. It already existed on the
public projection (#295/#300) and was absent from the heartbeat stanza — which
is the surface an operator opens first, and the one every report so far has
quoted.

NOT A REPORTING BUG, AND DELIBERATELY NOT CHANGED. `state: "healthy"` at
268 s is correct. `derivedReadinessReconcileState` re-derives `stalled` from
`inFlightMs > intervalMs * READINESS_RECONCILE_STALL_INTERVALS`, and that
constant is 10 — so the flip was due at 600 s and the observation window
(14:37Z-14:41Z) closed 5.5 minutes early. Lowering it is the trivially wrong
fix: public-health.ts documents #36's 61-minute post-boot hydration as the
reason a small multiple cries wolf on every cold container.

TESTS, both against the real production numbers:
- must-fire: a heartbeat carrying the bounds publishes both, and reports
  missedPasses 4 for the exact 268232/60000 pass above. Fail-first verified by
  ablation — with only public-health.ts and types.ts reverted it fails
  `expected undefined to be 5400000`.
- must-not-fire: a recorded `0` or negative bound is dropped rather than
  republished as an instant deadline, and an instance predating the fields
  still projects `healthy` with both absent. This one passes before and after
  by construction: it is the guard on the trivially wrong version, not a
  demonstration of the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
miyaontherelay added a commit that referenced this pull request Aug 25, 2026
…e sweep bounds that already exist (#379)

* fix(health): publish the bounds that can preempt a sweep, not just the cadence that cannot

THIS COMMIT DOES NOT ADD A BOUND. It publishes the ones that already exist,
because their absence from the health stanza has now been read twice as their
absence from the code — including in the brief that asked for this fix.

WHAT THE STANZA SAID. A wedged 0.1.76 published:

    "readinessReconcile": {
      "state": "healthy", "consecutiveFailures": 0,
      "failureThreshold": 3, "inFlightMs": 268232, "intervalMs": 60000
    }

`intervalMs` is a scheduler tick and cannot preempt anything. Next to an
`inFlightMs` climbing 1:1 with wall clock it is indistinguishable from an
unbounded hang, and there was no field that could tell the two apart. The
reading taken from it — "there is no timeoutMs, so nothing bounds this" — is
false, and it is the reading this stanza invites.

WHAT IS ACTUALLY BOUNDING THAT PASS. Three deadlines, all live on this path in
0.1.76: `relayfileOperationTimeoutMs` per call (#351/#368),
`readinessReconcileTimeoutMs` on the caller's wait (#296), and the aggregate
`sweepBudgetMs` from #374 — `#reconcileReadyIssues` -> `#runOnceWithReadinessDeadline`
-> `runOnce()` -> `#runOnceWithDiscoveryFence` -> `startDiscoverySweepBudget`.
`readinessReconcile` IS the discovery sweep's health stanza; sweep-budget.ts
names it as such. The pass was bounded. It was bounded at 90 minutes, because
`sweepBudgetMs` derives from `reconcileTimeoutMs`, so at 268 s it had 89
minutes left to run and no field said so.

Two numbers now ship: `timeoutMs` (ends the wait) and `sweepBudgetMs` (unwinds
the sweep and hands the lease back). The second is the one that answers "when
does this recover", which is the question every reader of this stanza has
actually been asking.

`missedPasses` also moves onto the heartbeat record. It already existed on the
public projection (#295/#300) and was absent from the heartbeat stanza — which
is the surface an operator opens first, and the one every report so far has
quoted.

NOT A REPORTING BUG, AND DELIBERATELY NOT CHANGED. `state: "healthy"` at
268 s is correct. `derivedReadinessReconcileState` re-derives `stalled` from
`inFlightMs > intervalMs * READINESS_RECONCILE_STALL_INTERVALS`, and that
constant is 10 — so the flip was due at 600 s and the observation window
(14:37Z-14:41Z) closed 5.5 minutes early. Lowering it is the trivially wrong
fix: public-health.ts documents #36's 61-minute post-boot hydration as the
reason a small multiple cries wolf on every cold container.

TESTS, both against the real production numbers:
- must-fire: a heartbeat carrying the bounds publishes both, and reports
  missedPasses 4 for the exact 268232/60000 pass above. Fail-first verified by
  ablation — with only public-health.ts and types.ts reverted it fails
  `expected undefined to be 5400000`.
- must-not-fire: a recorded `0` or negative bound is dropped rather than
  republished as an instant deadline, and an instance predating the fields
  still projects `healthy` with both absent. This one passes before and after
  by construction: it is the guard on the trivially wrong version, not a
  demonstration of the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

* fix(orchestrator): bound the completion release retry, which is what was actually spinning

The `no pid available to terminate ... during completion` lines repeating 15+
times in one evidence payload are a CO-SYMPTOM, not the cause. Fixing the PID
classification would have changed nothing, and this commit explains why before
it changes anything.

WHY THE MISSING PID IS NOT THE LOOP. `#releaseAndTerminateAgents` logs that
line when `#terminationRoots` returns `{ pids: [], status: 'unresolved' }`,
then falls through. Nothing on that branch reaches `failed[]` — only a throw
from `#fleet.release()` that is not `isAgentAlreadyGoneOnRelease` does. So the
three agents were re-attempted because their RELEASE kept failing, and the
no-PID line was printed once per agent per attempt on the way past.

WHY THE LOOP NEVER ENDED. `#finishDurableRelease` does not throw on a failed
release: it returns `false` and calls `#scheduleReleaseRetry`, which re-arms at
`DISPATCH_LIFECYCLE_RETRY_MS` — 1 000 ms, unbounded. Every re-arm therefore
arrives on the RESOLVED path, which is why the `.catch()` in both schedulers
never bounded it and why a bound written there would have been a fix that never
fired. The budget is charged at the scheduling point instead.

WHAT A PASS COSTS, WHICH IS WHY 1 Hz FOREVER IS NOT FREE. Each pass calls
`#terminationRoots` once per agent inside the release AND once per agent again
inside `#writeInFlightRegistry` — a process-table scan each — plus a durable
lifecycle read and write. For the three agents in the report that is order ten
scans and several state operations per second, indefinitely. #303 already
measured this exact shape once, at 1477 state GETs in 111 s, and bounded the
RATE of the capacity-wait re-arm in response. It deliberately left the COUNT
unbounded there, because waiting for capacity is legitimate.

DESIGN CHOICE: (a) BOUNDED RETRIES, NOT (b) RECLASSIFY NO-PID. Not chosen under
uncertainty — the code already tells the two cases apart, and it says (b) is
wrong. `#terminationRoots` returns `'missing'` for confirmed-gone (a remote
placement, or a process scan that came back missing AND a resolver that agreed)
and `'unresolved'` for could-not-determine (no resolver and no recorded pids,
an AMBIGUOUS scan, a resolver that returned nothing, or one that threw). The
error only fires on `'unresolved'`. Treating that as already-terminated would
mean skipping termination of a process that may well be alive — an ambiguous
scan is literally "more than one candidate matched" — leaving orphans holding
worktrees and slots. And it would not have stopped the spin regardless, per the
first section.

Release is also the opposite shape from #303's capacity wait, which is what
makes bounding the count right here and wrong there: it is the last step of a
work unit that is already finished — issue closed, writeback acknowledged,
batch slot returned — so a release that has failed ten times is not waiting for
anything. Ten attempts at the 1 s floor is ~10 s of genuine retry, which covers
a control-plane blip or a lease handover and does not cover a permanent
failure.

SCOPED SO IT CANNOT ABANDON WORK THAT WAS NEVER FAILING:
- Only release re-arms spend the budget. `#scheduleDispatchLifecycleRetry`
  takes an explicit `releaseAttempt` flag, so a `DispatchLifecycleCapacityError`
  or `DispatchLifecycleOwnedElsewhereError` — both legitimate waits on someone
  else — still retries forever, exactly as #303 intended.
- Progress refunds the budget, so ten bounds CONSECUTIVE no-progress passes
  rather than capping a slow multi-agent release. This terminates: an agent
  released once is checkpointed and skipped next pass, so the remaining set
  strictly shrinks and a refund can only be earned finitely often.
- The durable lifecycle is RETAINED on exhaustion. A takeover or a restart
  re-drives it from the persisted phase. This bounds one process's spin; it
  does not declare the work unit clean.
- Keyed by `dispatchLifecycleKey`, so the budget follows the work unit rather
  than an agent, a surface or a dispatcher — the AR-448 identity rule.

Exhaustion is logged at `error`, not `warn`, and increments
`dispatchLifecycleReleaseAbandoned`. Every layer of this failure so far has
been invisible until somebody read stderr by hand, and a work unit whose
cleanup this process has permanently given up on must not be inferable only
from the absence of further log lines.

TESTS (3), against a fleet that reproduces the production shape exactly —
`release()` throws for `issue-done` and `resolveAgentPid` returns
`'unresolved'`, so the same no-PID line is emitted on every pass:
- must-fire: the dead-letter counter reaches 1, the exhaustion error is logged,
  and three further seconds of wall clock buy no additional release attempts.
  Fail-first verified by ablation: with factory.ts reverted it fails after
  40 543 ms with `expected undefined to be 1` — the wait can only end in its
  own deadline, because the loop re-arms for as long as the process lives.
  That is a property of the loop, not of any number chosen in the test.
- must-not-fire: a release that succeeds still completes the work unit and
  releases each agent exactly once, with the counter unset. The trivially wrong
  way to stop a retry loop is to stop retrying.
- must-not-fire: a release that fails several times and then succeeds still
  completes, with the counter unset — the transient case the retry exists for.
  Both must-not-fires passed under the ablation too, which is what makes them
  guards rather than restatements of the fix.

RETRY CADENCE IS NOW AN INJECTABLE PORT, and that is a test-stability fix in
its own right rather than a convenience. Exhausting a ten-attempt budget at the
real 1 s floor costs ten real seconds per case; the first version of this suite
did exactly that and added 41 s to `factory.test.ts`. Run beside two other
files it pushed an already-300 s combination over an edge and four UNRELATED
tests began failing on timing — the reopen-fence and Slack-reply-route cases —
while the same three files passed on `origin/main` and `factory.test.ts` alone
passed 631/631 on the branch. Buying a fourth flake in this suite (it already
carries #342 and #373) to test a fix for a spin is the wrong trade.

`dispatchLifecycleRetryMs` follows the existing convention for exactly this —
`babysitterWakeUnreachableRetryMs`, `babysitterWakeUnreachableEscalateMs`,
`startupAgentExitDrainTimeoutMs` are all test-only port overrides of a built-in
timing. Only the delay between attempts moves; the BUDGET under test is the
real one. Overhead is now +4 s, the four unrelated failures are gone (709/709
on the same three files), and the ablation still fails with `expected undefined
to be 1` — unambiguously, because `dispatchLifecycleReleaseAbandoned` does not
exist on `origin/main` at any cadence.

The transient case sets a failure count on the fake rather than flipping a flag
from the test body, so it cannot race the cadence it runs under.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

* fix(orchestrator): make the release bound actually fire on the durable path

Answers three P1 findings on #379. The first is the important one: the bound
as first written DID NOT FIRE in production, and the review caught it.

1. THE BUDGET RESET ON THE PATH THAT MATTERS, SO THE BOUND NEVER FIRED.

   `#driveDispatchLifecycle` discards `#finishDurableRelease`'s boolean in its
   `phase === 'releasing'` branch, and that method returns `false` rather than
   throwing on a failed release. So a FAILED release makes the drive RESOLVE,
   and the scheduler's success handler ran on every re-arm — where it called
   `#clearReleaseAttempts`. The counter was zeroed once per pass and could
   never reach the cap.

   This is the same never-fires shape the first version of this commit
   correctly rejected in the `.catch()`, moved one layer over into the
   `.then()`. Diagnosing the resolved path as the live one and then putting the
   refund on it was the error.

   The refund is removed from the scheduler entirely. It now happens only where
   success is actually known: `#finishDurableRelease` clears the budget on real
   per-agent progress and again when the work unit completes.

   WHY THE ORIGINAL TESTS MISSED IT. `#usesDurableDispatchLifecycle()` is
   `durableOwnership ?? placementLocality === 'remote'`, and `FakeFleetClient`
   places locally, so all three original cases exercised `#scheduleReleaseRetry`'s
   own timer — which has no success handler and therefore no reset. The
   deployed Factory places remotely. The suite proved a property of the path
   production does not take.

   New must-fire on the DURABLE path (`RemoteLifecycleFleetClient` +
   `InMemoryStateStore`), asserting the counter SURVIVES ACROSS RE-ARMS rather
   than that a dead-letter is reachable by some path. Fail-first verified by
   ablation: restore the `#clearReleaseAttempts(key)` line and only that case
   fails, `expected undefined to be 1` after 10 125 ms, while the three local
   cases still pass — which is what pins the discrimination to the durable path.

2. THE WRONG BUDGET WAS CHARGED.

   The generic arm of the drive's `.catch()` re-arms for dispatch, publishing
   and recovery failures as well as releases, and it charged all of them. That
   would dead-letter a work unit that was never stuck in a release loop.

   Charging is now confined to `#scheduleReleaseRetry`, whose every caller is a
   release failure: the three inside `#finishDurableRelease`, and
   `#completeIssue`'s catch once `releaseReasonForRetry` is set. The generic
   re-arm passes no charge at all.

   Pinned by a call-site audit rather than by a behavioural test, and that is
   deliberate. I could not reach that arm from a realistic fixture — forcing
   durable lifecycle reads to throw makes the agent-exit handler fail before any
   lifecycle retry is scheduled, so a test built that way passes whether or not
   the narrowing is present. Confirmed by ablation: with `releaseAttempt = true`
   restored, the fixture-based version still passed, and instrumenting it showed
   zero `durable dispatch lifecycle retry failed` warnings — the branch was
   never entered. Shipping that would have been a test that proves nothing, so
   the audit states the structure instead.

   Known gap, stated plainly: a release failure that THREW out of
   `#finishDurableRelease` would reach the generic arm and re-arm unbounded.
   Every failure path in that method returns `false` and schedules its own
   retry, so this is not a reachable shape today, and if one appears it degrades
   to the pre-existing unbounded behaviour rather than to a wrong dead-letter.

3. THE DEAD-LETTER LEAKED THE SLOT.

   Trading an unbounded 1 Hz spin for a permanently leaked in-flight record is
   not obviously the better failure: a spin is loud and self-describing, while a
   leaked slot silently reduces dispatch capacity until the process is
   restarted. Local completion never calls `batch.complete`, so exhaustion left
   the work unit in flight forever.

   `#releaseDeadLetteredSlot` now hands the batch slot back, drops any
   uncompensated claim, rewrites the in-flight registry, and admits whatever was
   queued behind it — a freed slot nothing is admitted into is only half the
   repair. The durable lifecycle is still deliberately RETAINED in `releasing`,
   so a successor or restart re-drives the same cleanup with a fresh budget;
   freeing a process-local slot is not a terminal phase and does not declare the
   work clean. The work unit therefore ends up recoverable, never merely
   abandoned.

   Must-fire asserts the slot is released after exhaustion. Fail-first by
   ablation: stub the call out and it fails with the work unit still in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants